공유메모리 동시성 — 락 선택과 프로세스 간 함정
NOTE
공유메모리는 “같은 메모리를 보게 해주는 것”까지만 OS가 해 준다. 그 안에서 누가 언제 읽고 쓸지는 막아 주지 않으므로 여러 프로세스가 동시에 건드리면 race condition이 생긴다. 이 노트는 문제가 왜 생기는지, 락 수단이 세마포어만이 아니라는 것, 그리고 프로세스 간 락에서만 생기는 함정(락을 쥔 채 프로세스 사망 등)을 정리한다.
상위 허브: 공유메모리 한눈에 보기
1. 왜 동시성 이슈가 생기나
“같은 주소”는 아니다. 가상 주소는 프로세스마다 다를 수 있다(A는 0x7f00…, B는 0x7e90…). 하지만 가리키는 물리 메모리가 같으므로 한쪽이 쓰는 동안 다른 쪽이 읽거나 쓰면 꼬인다.
프로세스 A 가상 주소 ─┐
├──→ 같은 물리 메모리 (count = 5)
프로세스 B 가상 주소 ─┘
시간 →
A: count 읽음(5) ───────────────→ 6 으로 씀
B: count 읽음(5) ──→ 6 으로 씀
결과: 6 (둘 다 +1 했는데 7이 아님)
count++ 한 줄도 읽기 → 더하기 → 쓰기의 여러 CPU 명령이라 중간에 다른 프로세스가 끼어들 수 있다. 스레드 동시성 문제와 본질이 같다 (→ 스레드 동시성 문제 정리, IPC와 스레드 메모리 공유 비교).
읽기만 하는 쪽도 안전하지 않다
구조체 여러 필드를 쓰는 도중에 읽으면 “일부만 갱신된 상태”(torn read)를 볼 수 있다. 읽는 쪽도 같은 락으로 보호하거나, 버전 번호 등으로 일관성을 확인해야 한다.
2. 락은 세마포어로만 하는가? — 아니다
| 방법 | 설명 | 특징 |
|---|---|---|
System V 세마포어 (semget, semop) | 커널이 관리하는 세마포어 세트 | 전통 방식. SEM_UNDO로 프로세스가 죽으면 커널이 값을 자동 복구 |
POSIX 세마포어 (sem_open, sem_wait/sem_post) | 이름 있는 것, 또는 공유메모리 안에 sem_t를 두는 것 | System V보다 단순 |
| 프로세스 공유 뮤텍스 | pthread_mutex_t를 공유메모리 안에 두고 PTHREAD_PROCESS_SHARED 지정 | 락이 데이터 옆에 있어 편하고, 경합이 없으면 커널 호출 없이 빠름 |
파일 락 (flock, fcntl) | 파일(또는 락 전용 파일)에 잠금 | 단순하지만 용도가 한정적 |
원자적 연산 (__atomic, C11 atomic) | 락 없이 카운터 증감, CAS | 가장 빠르나 단순한 값에만 가능 → 락 없는 카운터 |
선택 가이드
값 하나(카운터·플래그)만 갱신 → 원자적 연산
여러 필드를 묶어서 일관되게 갱신 → 프로세스 공유 뮤텍스 (또는 세마포어)
프로세스 사망 시 락 자동 복구가 중요 → System V 세마포어 + SEM_UNDO, 또는 robust 뮤텍스
동시에 N개까지 허용(자원 풀) → 카운팅 세마포어
세마포어와 뮤텍스의 개념 차이, 그리고 spinlock·rwlock·RCU 등 락 구현 전반과 select/epoll(락이 아닌 I/O 대기)과의 구분은 세마포어와 동시성 제어 4~5절을 참고.
3. 프로세스 간 락에서만 생기는 함정 3가지
3.1 락을 쥔 채 프로세스가 죽는 경우
스레드는 같은 프로세스라 함께 죽지만, 프로세스는 따로 죽는다. 락을 쥔 프로세스가 죽으면 락이 영원히 안 풀려 다른 프로세스가 무한 대기한다.
| 대응 | 설명 |
|---|---|
System V 세마포어 SEM_UNDO | 프로세스 종료 시 커널이 그 프로세스가 올린/내린 세마포어 값을 되돌린다 |
robust 뮤텍스 (PTHREAD_MUTEX_ROBUST) | 소유자가 죽은 뮤텍스를 다음에 잠그는 쪽이 EOWNERDEAD를 받는다 → 데이터를 복구한 뒤 pthread_mutex_consistent |
3.2 일반 뮤텍스는 프로세스 간에 안 통한다
속성 없이 만든 pthread_mutex_t는 같은 프로세스의 스레드끼리만 보장된다. 공유메모리에 둔다고 되는 것이 아니고 PTHREAD_PROCESS_SHARED를 반드시 지정해야 한다.
struct Shared {
pthread_mutex_t lock; /* 락을 보호 대상 데이터와 같은 공유메모리 안에 둔다 */
int count;
};
/* 생성자만 1번 초기화 */
pthread_mutexattr_t a;
pthread_mutexattr_init(&a);
pthread_mutexattr_setpshared(&a, PTHREAD_PROCESS_SHARED); /* 프로세스 간 공유 */
pthread_mutexattr_setrobust(&a, PTHREAD_MUTEX_ROBUST); /* 소유자 사망 감지 */
pthread_mutex_init(&s->lock, &a);
/* 사용하는 모든 프로세스 */
int r = pthread_mutex_lock(&s->lock);
if (r == EOWNERDEAD) { /* 이전 소유자가 락을 쥔 채 죽음 */
/* 공유 데이터가 중간 상태일 수 있으니 점검·복구 후 */
pthread_mutex_consistent(&s->lock);
}
s->count++;
pthread_mutex_unlock(&s->lock);3.3 락 객체 자체의 위치와 레이아웃
- 락(뮤텍스,
sem_t)을 보호 대상과 같은 구조체 안에 두는 패턴이 흔하다. 이때 포인터가 아닌 값으로 두고(포인터는 프로세스마다 주소가 다름), 구조체 레이아웃(패딩)이 모든 프로세스에서 같아야 한다 → 구조체 패딩과 멤버 정렬. - 초기화는 생성자가 한 번만. 사용 측이
pthread_mutex_init을 다시 부르면 이미 잠겨 있는 락을 덮어쓰는 사고가 난다.
4. 동시성 체크리스트
| 점검 항목 | 확인 |
|---|---|
| 공유 데이터를 쓰는 모든 경로가 같은 락을 쓰는가 | 한 군데라도 빠지면 의미 없음 |
| 읽는 쪽도 락(또는 일관성 확인)을 쓰는가 | torn read 방지 |
| 락이 프로세스 간 공유 속성인가 | PTHREAD_PROCESS_SHARED / 세마포어 |
| 락 보유 중 프로세스 사망을 고려했는가 | SEM_UNDO / robust 뮤텍스 |
| 락 초기화가 한 곳(생성자)에서 한 번만 이루어지는가 | 재초기화 사고 방지 |
| 구조체 안에 포인터를 저장하지 않았는가 | 오프셋 사용 (mmap 노트) |
| 락 순서가 일정한가 (여러 락 사용 시) | 데드락 방지 |
5. 한눈에 정리
| 질문 | 답 |
|---|---|
| 동시성 이슈가 나오나 | 그렇다. 가상 주소는 달라도 같은 물리 메모리라서 race가 생긴다 |
| 락은 세마포어뿐인가 | 아니다. 프로세스 공유 뮤텍스, 파일 락, 원자적 연산도 있다 |
| 일반 뮤텍스를 공유메모리에 두면 | 안 된다. PTHREAD_PROCESS_SHARED 필수 |
| 락 보유 중 프로세스가 죽으면 | 무한 대기 위험 → SEM_UNDO / robust 뮤텍스(EOWNERDEAD) |
| 읽기만 해도 락이 필요한가 | 여러 필드를 읽는다면 필요 (torn read) |